<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>实时语音识别测试</title>
    <style>
        body {
            font-family: 'Microsoft YaHei', Arial, sans-serif;
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
        }
        
        .container {
            background: white;
            border-radius: 15px;
            padding: 30px;
            box-shadow: 0 10px 30px rgba(0,0,0,0.2);
        }
        
        h1 {
            text-align: center;
            color: #333;
            margin-bottom: 30px;
        }
        
        .controls {
            display: flex;
            gap: 15px;
            justify-content: center;
            margin-bottom: 30px;
            flex-wrap: wrap;
        }
        
        button {
            padding: 12px 24px;
            border: none;
            border-radius: 25px;
            font-size: 16px;
            cursor: pointer;
            transition: all 0.3s ease;
            min-width: 120px;
        }
        
        .start-btn {
            background: #4CAF50;
            color: white;
        }
        
        .start-btn:hover {
            background: #45a049;
            transform: translateY(-2px);
        }
        
        .stop-btn {
            background: #f44336;
            color: white;
        }
        
        .stop-btn:hover {
            background: #da190b;
            transform: translateY(-2px);
        }
        
        .clear-btn {
            background: #2196F3;
            color: white;
        }
        
        .clear-btn:hover {
            background: #1976D2;
            transform: translateY(-2px);
        }
        
        button:disabled {
            background: #cccccc;
            cursor: not-allowed;
            transform: none;
        }
        
        .config {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
            gap: 15px;
            margin-bottom: 20px;
            padding: 20px;
            background: #f8f9fa;
            border-radius: 10px;
        }
        
        .config-item {
            display: flex;
            flex-direction: column;
        }
        
        .config-item label {
            font-weight: bold;
            margin-bottom: 5px;
            color: #555;
        }
        
        .config-item input, .config-item select {
            padding: 8px;
            border: 1px solid #ddd;
            border-radius: 5px;
            font-size: 14px;
        }
        
        .status {
            display: flex;
            align-items: center;
            justify-content: center;
            gap: 10px;
            margin-bottom: 20px;
            padding: 15px;
            border-radius: 10px;
            font-weight: bold;
        }
        
        .status.disconnected {
            background: #ffebee;
            color: #c62828;
        }
        
        .status.connected {
            background: #e8f5e8;
            color: #2e7d32;
        }
        
        .status.recording {
            background: #fff3e0;
            color: #ef6c00;
        }
        
        .status-dot {
            width: 12px;
            height: 12px;
            border-radius: 50%;
            animation: pulse 2s infinite;
        }
        
        .status.disconnected .status-dot {
            background: #c62828;
        }
        
        .status.connected .status-dot {
            background: #2e7d32;
        }
        
        .status.recording .status-dot {
            background: #ef6c00;
        }
        
        @keyframes pulse {
            0% { opacity: 1; }
            50% { opacity: 0.5; }
            100% { opacity: 1; }
        }
        
        .result-container {
            border: 1px solid #ddd;
            border-radius: 10px;
            padding: 20px;
            min-height: 200px;
            background: #fafafa;
            margin-bottom: 20px;
        }
        
        .result-title {
            font-weight: bold;
            margin-bottom: 15px;
            color: #333;
        }
        
        .result-text {
            line-height: 1.6;
            white-space: pre-wrap;
            word-wrap: break-word;
        }
        
        .online-result {
            color: #666;
            font-style: italic;
            margin-bottom: 10px;
            padding: 10px;
            background: #f0f0f0;
            border-radius: 5px;
            border-left: 3px solid #2196F3;
        }
        
        .offline-result {
            color: #333;
            font-weight: bold;
            margin-bottom: 10px;
            padding: 10px;
            background: #e8f5e8;
            border-radius: 5px;
            border-left: 3px solid #4CAF50;
        }
        
        .logs {
            background: #000;
            color: #0f0;
            padding: 15px;
            border-radius: 10px;
            font-family: 'Courier New', monospace;
            font-size: 12px;
            max-height: 200px;
            overflow-y: auto;
        }
        
        .audio-visualizer {
            width: 100%;
            height: 60px;
            background: #f0f0f0;
            border: 1px solid #ddd;
            border-radius: 5px;
            margin: 10px 0;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>🎤 实时语音识别测试</h1>
        
        <div class="config">
            <div class="config-item">
                <label for="serverUrl">服务器地址:</label>
                <input type="text" id="serverUrl" value="ws://localhost:10095" placeholder="ws://localhost:10095">
            </div>
            <div class="config-item">
                <label for="mode">识别模式:</label>
                <select id="mode">
                    <option value="2pass">2pass (推荐)</option>
                    <option value="online">仅在线</option>
                    <option value="offline">仅离线</option>
                </select>
            </div>
            <div class="config-item">
                <label for="chunkInterval">发送间隔 (已优化为实时):</label>
                <input type="number" id="chunkInterval" value="10" min="1" max="50" disabled>
            </div>
        </div>
        
        <div class="status disconnected" id="status">
            <div class="status-dot"></div>
            <span id="statusText">未连接</span>
        </div>
        
        <div class="controls">
            <button class="start-btn" id="connectBtn" onclick="connect()">连接服务器</button>
            <button class="start-btn" id="startBtn" onclick="startRecording()" disabled>开始录音</button>
            <button class="stop-btn" id="stopBtn" onclick="stopRecording()" disabled>停止录音</button>
            <button class="clear-btn" onclick="clearResults()">清空结果</button>
        </div>
        
        <canvas class="audio-visualizer" id="visualizer"></canvas>
        
        <div class="result-container">
            <div class="result-title">识别结果:</div>
            <div class="result-text" id="results"></div>
        </div>
        
        <div class="logs" id="logs"></div>
    </div>

    <script>
        let ws = null;
        let audioContext = null;
        let mediaStream = null;
        let workletNode = null;
        let isRecording = false;
        let isConnected = false;
        
        // 文本累积变量（仿照Python客户端）
        let textPrint2passOnline = "";
        let textPrint2passOffline = "";
        let lastResultDiv = null;
        
        const connectBtn = document.getElementById('connectBtn');
        const startBtn = document.getElementById('startBtn');
        const stopBtn = document.getElementById('stopBtn');
        const status = document.getElementById('status');
        const statusText = document.getElementById('statusText');
        const results = document.getElementById('results');
        const logs = document.getElementById('logs');
        
        // 音频可视化
        const canvas = document.getElementById('visualizer');
        const canvasCtx = canvas.getContext('2d');
        let analyser = null;
        let dataArray = null;
        
        function log(message) {
            const timestamp = new Date().toLocaleTimeString();
            logs.innerHTML += `[${timestamp}] ${message}\n`;
            logs.scrollTop = logs.scrollHeight;
            console.log(message);
        }
        
        function updateStatus(state, text) {
            status.className = `status ${state}`;
            statusText.textContent = text;
        }
        
        async function connect() {
            if (isConnected) {
                disconnect();
                return;
            }
            
            const serverUrl = document.getElementById('serverUrl').value;
            
            try {
                log('正在连接服务器...');
                ws = new WebSocket(serverUrl, ['binary']);
                
                ws.onopen = function() {
                    log('WebSocket连接已建立');
                    isConnected = true;
                    updateStatus('connected', '已连接');
                    connectBtn.textContent = '断开连接';
                    connectBtn.className = 'stop-btn';
                    startBtn.disabled = false;
                    
                    // 发送配置信息（匹配Python客户端）
                    const config = {
                        mode: document.getElementById('mode').value,
                        chunk_interval: 10, // 固定值，但音频实时发送
                        chunk_size: [5, 10, 5],
                        encoder_chunk_look_back: 4,
                        decoder_chunk_look_back: 0, // 匹配Python客户端默认值
                        wav_name: 'web_client',
                        is_speaking: false,
                        itn: true // 启用反向文本规范化
                    };
                    ws.send(JSON.stringify(config));
                    log('已发送配置: ' + JSON.stringify(config));
                };
                

                
                ws.onmessage = function(event) {
                    try {
                        const data = JSON.parse(event.data);
                        log(`收到识别结果: ${data.mode} - ${data.text}`);
                        
                        let displayText = "";
                        let isOnline = false;
                        
                        if (data.mode === "2pass-online") {
                            // 在线识别：累积在线文本
                            textPrint2passOnline += data.text;
                            displayText = textPrint2passOffline + textPrint2passOnline;
                            isOnline = true;
                        } else if (data.mode === "2pass-offline") {
                            // 离线识别：清空在线文本，累积离线文本
                            textPrint2passOnline = "";
                            textPrint2passOffline += data.text;
                            displayText = textPrint2passOffline + textPrint2passOnline;
                            isOnline = false;
                        } else if (data.mode === "online") {
                            // 纯在线模式
                            textPrint2passOnline += data.text;
                            displayText = textPrint2passOnline;
                            isOnline = true;
                        } else if (data.mode === "offline") {
                            // 纯离线模式
                            displayText = data.text;
                            isOnline = false;
                        } else {
                            displayText = data.text;
                        }
                        
                        // 更新或创建结果显示
                        if (isOnline && lastResultDiv && lastResultDiv.className === 'online-result') {
                            // 更新现有的在线结果
                            lastResultDiv.innerHTML = `<strong>[实时累积]</strong> ${displayText}`;
                        } else {
                            // 创建新的结果div
                            const resultDiv = document.createElement('div');
                            if (isOnline) {
                                resultDiv.className = 'online-result';
                                resultDiv.innerHTML = `<strong>[实时累积]</strong> ${displayText}`;
                            } else {
                                resultDiv.className = 'offline-result';
                                resultDiv.innerHTML = `<strong>[最终完整]</strong> ${displayText}`;
                            }
                            results.appendChild(resultDiv);
                            lastResultDiv = resultDiv;
                        }
                        
                        results.scrollTop = results.scrollHeight;
                        
                    } catch (e) {
                        log('解析服务器响应失败: ' + e.message);
                    }
                };
                
                ws.onerror = function(error) {
                    log('WebSocket错误: ' + error);
                };
                
                ws.onclose = function() {
                    log('WebSocket连接已关闭');
                    disconnect();
                };
                
            } catch (error) {
                log('连接失败: ' + error.message);
                updateStatus('disconnected', '连接失败');
            }
        }
        
        function disconnect() {
            if (ws) {
                ws.close();
                ws = null;
            }
            
            if (isRecording) {
                stopRecording();
            }
            
            isConnected = false;
            updateStatus('disconnected', '未连接');
            connectBtn.textContent = '连接服务器';
            connectBtn.className = 'start-btn';
            startBtn.disabled = true;
            stopBtn.disabled = true;
        }
        
        async function startRecording() {
            if (!isConnected) {
                alert('请先连接服务器');
                return;
            }
            
            try {
                log('请求麦克风权限...');
                mediaStream = await navigator.mediaDevices.getUserMedia({
                    audio: {
                        sampleRate: 16000,
                        channelCount: 1,
                        echoCancellation: true,
                        noiseSuppression: true,
                        autoGainControl: true
                    }
                });
                
                audioContext = new AudioContext({ sampleRate: 16000 });
                const source = audioContext.createMediaStreamSource(mediaStream);
                
                // 创建分析器用于可视化
                analyser = audioContext.createAnalyser();
                analyser.fftSize = 256;
                dataArray = new Uint8Array(analyser.frequencyBinCount);
                source.connect(analyser);
                
                // 计算合适的buffer大小，匹配Python客户端
                // Python: 60ms音频 = 16000 * 0.06 = 960 samples
                const chunkInterval = parseInt(document.getElementById('chunkInterval').value);
                const targetChunkMs = 60; // 60ms chunk like Python client
                const samplesPerChunk = Math.floor(16000 * targetChunkMs / 1000); // 960 samples
                
                // 使用较小的processor buffer以减少延迟
                const processor = audioContext.createScriptProcessor(1024, 1, 1);
                
                let audioBuffer = new Float32Array(0);
                let chunkCounter = 0;
                let lastSendTime = 0;
                const sendInterval = 60; // 60ms间隔，匹配Python客户端的chunk时间
                
                processor.onaudioprocess = function(event) {
                    if (!isRecording) return;
                    
                    const inputData = event.inputBuffer.getChannelData(0);
                    
                    // 累积音频数据到目标chunk大小
                    const newBuffer = new Float32Array(audioBuffer.length + inputData.length);
                    newBuffer.set(audioBuffer);
                    newBuffer.set(inputData, audioBuffer.length);
                    audioBuffer = newBuffer;
                    
                    // 当累积足够的样本时，按时间间隔发送
                    const currentTime = Date.now();
                    while (audioBuffer.length >= samplesPerChunk && 
                           (currentTime - lastSendTime >= sendInterval || lastSendTime === 0)) {
                        
                        const chunkData = audioBuffer.slice(0, samplesPerChunk);
                        audioBuffer = audioBuffer.slice(samplesPerChunk);
                        
                        // 转换为16位PCM
                        const pcmData = new Int16Array(chunkData.length);
                        for (let i = 0; i < chunkData.length; i++) {
                            const sample = Math.max(-1, Math.min(1, chunkData[i]));
                            pcmData[i] = sample < 0 ? sample * 0x8000 : sample * 0x7FFF;
                        }
                        
                        // 按间隔发送音频数据
                        if (ws && ws.readyState === WebSocket.OPEN) {
                            ws.send(pcmData.buffer);
                            lastSendTime = currentTime;
                            log(`发送音频块: ${chunkData.length} 样本, 时间间隔: ${currentTime - (lastSendTime - sendInterval)}ms`);
                        }
                        break; // 一次只发送一个chunk
                    }
                };
                
                source.connect(processor);
                processor.connect(audioContext.destination);
                
                isRecording = true;
                updateStatus('recording', '正在录音...');
                startBtn.disabled = true;
                stopBtn.disabled = false;
                
                // 重置文本累积变量
                textPrint2passOnline = "";
                textPrint2passOffline = "";
                lastResultDiv = null;
                
                // 通知服务器开始说话
                if (ws && ws.readyState === WebSocket.OPEN) {
                    ws.send(JSON.stringify({ is_speaking: true }));
                }
                
                log('开始录音');
                
                // 开始音频可视化
                visualize();
                
            } catch (error) {
                log('录音失败: ' + error.message);
                alert('无法访问麦克风: ' + error.message);
            }
        }
        
        function stopRecording() {
            if (!isRecording) return;
            
            isRecording = false;
            
            if (mediaStream) {
                mediaStream.getTracks().forEach(track => track.stop());
                mediaStream = null;
            }
            
            if (audioContext) {
                audioContext.close();
                audioContext = null;
            }
            
            // 通知服务器停止说话
            if (ws && ws.readyState === WebSocket.OPEN) {
                ws.send(JSON.stringify({ is_speaking: false }));
            }
            
            updateStatus('connected', '已连接');
            startBtn.disabled = false;
            stopBtn.disabled = true;
            
            log('停止录音');
        }
        
        function visualize() {
            if (!isRecording || !analyser) return;
            
            requestAnimationFrame(visualize);
            
            analyser.getByteFrequencyData(dataArray);
            
            canvasCtx.fillStyle = '#f0f0f0';
            canvasCtx.fillRect(0, 0, canvas.width, canvas.height);
            
            const barWidth = canvas.width / dataArray.length * 2;
            let x = 0;
            
            for (let i = 0; i < dataArray.length; i++) {
                const barHeight = (dataArray[i] / 255) * canvas.height;
                
                const gradient = canvasCtx.createLinearGradient(0, canvas.height, 0, canvas.height - barHeight);
                gradient.addColorStop(0, '#4CAF50');
                gradient.addColorStop(0.5, '#2196F3');
                gradient.addColorStop(1, '#9C27B0');
                
                canvasCtx.fillStyle = gradient;
                canvasCtx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
                
                x += barWidth + 1;
            }
        }
        
        function clearResults() {
            results.innerHTML = '';
            logs.innerHTML = '';
            // 重置文本累积变量
            textPrint2passOnline = "";
            textPrint2passOffline = "";
            lastResultDiv = null;
        }
        
        // 页面卸载时清理资源
        window.addEventListener('beforeunload', function() {
            if (isRecording) {
                stopRecording();
            }
            if (isConnected) {
                disconnect();
            }
        });
        
        // 初始化canvas尺寸
        function resizeCanvas() {
            canvas.width = canvas.offsetWidth;
            canvas.height = canvas.offsetHeight;
        }
        
        window.addEventListener('load', resizeCanvas);
        window.addEventListener('resize', resizeCanvas);
        
        log('页面加载完成，请点击"连接服务器"开始');
    </script>
</body>
</html> 